/*
 * Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package feign;

import static feign.Util.enumForName;
import static java.util.Objects.nonNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

import feign.Logger.Level;
import feign.Request.ProtocolVersion;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

public class LoggerTest {
  public final MockWebServer server = new MockWebServer();
  public final RecordingLogger logger = new RecordingLogger();

  interface SendsStuff {

    @RequestLine("POST /")
    @Headers({"Content-Type: application/json", "X-Token: qwerty"})
    @Body(
        "%7B\"customer_name\": \"{customer_name}\", \"user_name\": \"{user_name}\", \"password\":"
            + " \"{password}\"%7D")
    String login(
        @Param("customer_name") String customer,
        @Param("user_name") String user,
        @Param("password") String password);
  }

  public static class LogLevelEmitsTest extends LoggerTest {

    private Level logLevel;

    public void initLogLevelEmitsTest(Level logLevel, List<String> expectedMessages) {
      this.logLevel = logLevel;
      logger.expectMessages(expectedMessages);
    }

    public static Iterable<Object[]> data() {
      return Arrays.asList(
          new Object[][] {
            {Level.NONE, Collections.emptyList()},
            {
              Level.BASIC,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1",
                  "\\[SendsStuff#login\\] <--- HTTP/1.1 200 OK \\([0-9]+ms\\)")
            },
            {
              Level.HEADERS,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1",
                  "\\[SendsStuff#login\\] Content-Length: 80",
                  "\\[SendsStuff#login\\] Content-Type: application/json",
                  "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)",
                  "\\[SendsStuff#login\\] <--- HTTP/1.1 200 OK \\([0-9]+ms\\)",
                  "\\[SendsStuff#login\\] content-length: 3",
                  "\\[SendsStuff#login\\] <--- END HTTP \\(3-byte body\\)")
            },
            {
              Level.FULL,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1",
                  "\\[SendsStuff#login\\] Content-Length: 80",
                  "\\[SendsStuff#login\\] Content-Type: application/json",
                  "\\[SendsStuff#login\\] ",
                  "\\[SendsStuff#login\\] \\{\"customer_name\": \"netflix\", \"user_name\":"
                      + " \"denominator\", \"password\": \"password\"\\}",
                  "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)",
                  "\\[SendsStuff#login\\] <--- HTTP/1.1 200 OK \\([0-9]+ms\\)",
                  "\\[SendsStuff#login\\] content-length: 3",
                  "\\[SendsStuff#login\\] ",
                  "\\[SendsStuff#login\\] foo",
                  "\\[SendsStuff#login\\] <--- END HTTP \\(3-byte body\\)")
            }
          });
    }

    @MethodSource("data")
    @ParameterizedTest
    void levelEmits(Level logLevel, List<String> expectedMessages) {
      initLogLevelEmitsTest(logLevel, expectedMessages);
      server.enqueue(new MockResponse().setHeader("Y-Powered-By", "Mock").setBody("foo"));

      SendsStuff api =
          Feign.builder()
              .logger(logger)
              .logLevel(logLevel)
              .target(SendsStuff.class, "http://localhost:" + server.getPort());

      api.login("netflix", "denominator", "password");
    }
  }

  public static class ReasonPhraseOptionalTest extends LoggerTest {

    private Level logLevel;

    public void initReasonPhraseOptional(Level logLevel, List<String> expectedMessages) {
      this.logLevel = logLevel;
      logger.expectMessages(expectedMessages);
    }

    public static Iterable<Object[]> data() {
      return Arrays.asList(
          new Object[][] {
            {
              Level.BASIC,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1",
                  "\\[SendsStuff#login\\] <--- HTTP/1.1 200 \\([0-9]+ms\\)")
            },
          });
    }

    @MethodSource("data")
    @ParameterizedTest
    void reasonPhraseOptional(Level logLevel, List<String> expectedMessages) {
      initReasonPhraseOptional(logLevel, expectedMessages);
      server.enqueue(new MockResponse().setStatus("HTTP/1.1 " + 200));

      SendsStuff api =
          Feign.builder()
              .logger(logger)
              .logLevel(logLevel)
              .target(SendsStuff.class, "http://localhost:" + server.getPort());

      api.login("netflix", "denominator", "password");
    }
  }

  public static class HttpProtocolVersionTest extends LoggerTest {

    private Level logLevel;
    private String protocolVersionName;

    public void initHttpProtocolVersionTest(
        Level logLevel, String protocolVersionName, List<String> expectedMessages) {
      this.logLevel = logLevel;
      this.protocolVersionName = protocolVersionName;
      logger.expectMessages(expectedMessages);
    }

    public static Iterable<Object[]> data() {
      return Arrays.asList(
          new Object[][] {
            {
              Level.BASIC,
              null,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1",
                  "\\[SendsStuff#login\\] <--- HTTP/1.1 200 \\([0-9]+ms\\)")
            },
            {
              Level.BASIC,
              "HTTP/1.1",
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1",
                  "\\[SendsStuff#login\\] <--- HTTP/1.1 200 \\([0-9]+ms\\)")
            },
            {
              Level.BASIC,
              "HTTP/2.0",
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1",
                  "\\[SendsStuff#login\\] <--- HTTP/2.0 200 \\([0-9]+ms\\)")
            },
            {
              Level.BASIC,
              "HTTP-XYZ",
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1",
                  "\\[SendsStuff#login\\] <--- UNKNOWN 200 \\([0-9]+ms\\)")
            }
          });
    }

    @MethodSource("data")
    @ParameterizedTest
    void httpProtocolVersion(
        Level logLevel, String protocolVersionName, List<String> expectedMessages) {
      initHttpProtocolVersionTest(logLevel, protocolVersionName, expectedMessages);
      server.enqueue(new MockResponse().setStatus("HTTP/1.1 " + 200));

      SendsStuff api =
          Feign.builder()
              .client(new TestProtocolVersionClient(protocolVersionName))
              .logger(logger)
              .logLevel(logLevel)
              .target(SendsStuff.class, "http://localhost:" + server.getPort());

      api.login("netflix", "denominator", "password");
    }
  }

  public static class ReadTimeoutEmitsTest extends LoggerTest {

    private Level logLevel;

    public void initReadTimeoutEmitsTest(Level logLevel, List<String> expectedMessages) {
      this.logLevel = logLevel;
      logger.expectMessages(expectedMessages);
    }

    public static Iterable<Object[]> data() {
      return Arrays.asList(
          new Object[][] {
            {Level.NONE, Collections.emptyList()},
            {
              Level.BASIC,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1",
                  "\\[SendsStuff#login\\] <--- ERROR SocketTimeoutException: Read timed out"
                      + " \\([0-9]+ms\\)")
            },
            {
              Level.HEADERS,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1",
                  "\\[SendsStuff#login\\] Content-Length: 80",
                  "\\[SendsStuff#login\\] Content-Type: application/json",
                  "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)",
                  "\\[SendsStuff#login\\] <--- ERROR SocketTimeoutException: Read timed out"
                      + " \\([0-9]+ms\\)")
            },
            {
              Level.FULL,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1",
                  "\\[SendsStuff#login\\] Content-Length: 80",
                  "\\[SendsStuff#login\\] Content-Type: application/json",
                  "\\[SendsStuff#login\\] ",
                  "\\[SendsStuff#login\\] \\{\"customer_name\": \"netflix\", \"user_name\":"
                      + " \"denominator\", \"password\": \"password\"\\}",
                  "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)",
                  "\\[SendsStuff#login\\] <--- ERROR SocketTimeoutException: Read timed out"
                      + " \\([0-9]+ms\\)",
                  "(?s)\\[SendsStuff#login\\] java.net.SocketTimeoutException: Read timed out.*",
                  "\\[SendsStuff#login\\] <--- END ERROR")
            }
          });
    }

    @MethodSource("data")
    @ParameterizedTest
    void levelEmitsOnReadTimeout(Level logLevel, List<String> expectedMessages) {
      initReadTimeoutEmitsTest(logLevel, expectedMessages);
      server.enqueue(new MockResponse().throttleBody(1, 1, TimeUnit.SECONDS).setBody("foo"));

      SendsStuff api =
          Feign.builder()
              .logger(logger)
              .logLevel(logLevel)
              .options(
                  new Request.Options(
                      10 * 1000, TimeUnit.MILLISECONDS, 50, TimeUnit.MILLISECONDS, true))
              .retryer(
                  new Retryer() {
                    @Override
                    public void continueOrPropagate(RetryableException e) {
                      throw e;
                    }

                    @Override
                    public Retryer clone() {
                      return this;
                    }
                  })
              .target(SendsStuff.class, "http://localhost:" + server.getPort());

      assertThrows(FeignException.class, () -> api.login("netflix", "denominator", "password"));
    }
  }

  public static class UnknownHostEmitsTest extends LoggerTest {

    private Level logLevel;

    public void initUnknownHostEmitsTest(Level logLevel, List<String> expectedMessages) {
      this.logLevel = logLevel;
      logger.expectMessages(expectedMessages);
    }

    public static Iterable<Object[]> data() {
      return Arrays.asList(
          new Object[][] {
            {Level.NONE, Collections.emptyList()},
            {
              Level.BASIC,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://non-exist.invalid/ HTTP/1.1",
                  "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: non-exist.invalid"
                      + " \\([0-9]+ms\\)")
            },
            {
              Level.HEADERS,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://non-exist.invalid/ HTTP/1.1",
                  "\\[SendsStuff#login\\] Content-Length: 80",
                  "\\[SendsStuff#login\\] Content-Type: application/json",
                  "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)",
                  "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: non-exist.invalid"
                      + " \\([0-9]+ms\\)")
            },
            {
              Level.FULL,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://non-exist.invalid/ HTTP/1.1",
                  "\\[SendsStuff#login\\] Content-Length: 80",
                  "\\[SendsStuff#login\\] Content-Type: application/json",
                  "\\[SendsStuff#login\\] ",
                  "\\[SendsStuff#login\\] \\{\"customer_name\": \"netflix\", \"user_name\":"
                      + " \"denominator\", \"password\": \"password\"\\}",
                  "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)",
                  "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: non-exist.invalid"
                      + " \\([0-9]+ms\\)",
                  "(?s)\\[SendsStuff#login\\] java.net.UnknownHostException: non-exist.invalid.*",
                  "\\[SendsStuff#login\\] <--- END ERROR")
            }
          });
    }

    @MethodSource("data")
    @ParameterizedTest
    void unknownHostEmits(Level logLevel, List<String> expectedMessages) {
      initUnknownHostEmitsTest(logLevel, expectedMessages);
      SendsStuff api =
          Feign.builder()
              .logger(logger)
              .logLevel(logLevel)
              .retryer(
                  new Retryer() {
                    @Override
                    public void continueOrPropagate(RetryableException e) {
                      throw e;
                    }

                    @Override
                    public Retryer clone() {
                      return this;
                    }
                  })
              .target(SendsStuff.class, "http://non-exist.invalid");

      assertThrows(FeignException.class, () -> api.login("netflix", "denominator", "password"));
    }
  }

  public static class FormatCharacterTest extends LoggerTest {

    private Level logLevel;

    public void initFormatCharacterTest(Level logLevel, List<String> expectedMessages) {
      this.logLevel = logLevel;
      logger.expectMessages(expectedMessages);
    }

    public static Iterable<Object[]> data() {
      return Arrays.asList(
          new Object[][] {
            {Level.NONE, Collections.emptyList()},
            {
              Level.BASIC,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://non-exist.invalid/ HTTP/1.1",
                  "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: non-exist.invalid"
                      + " \\([0-9]+ms\\)")
            },
            {
              Level.HEADERS,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://non-exist.invalid/ HTTP/1.1",
                  "\\[SendsStuff#login\\] Content-Length: 80",
                  "\\[SendsStuff#login\\] Content-Type: application/json",
                  "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)",
                  "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: non-exist.invalid"
                      + " \\([0-9]+ms\\)")
            },
            {
              Level.FULL,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://non-exist.invalid/ HTTP/1.1",
                  "\\[SendsStuff#login\\] Content-Length: 80",
                  "\\[SendsStuff#login\\] Content-Type: application/json",
                  "\\[SendsStuff#login\\] ",
                  "\\[SendsStuff#login\\] \\{\"customer_name\": \"netflix\", \"user_name\":"
                      + " \"denominator\", \"password\": \"password\"\\}",
                  "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)",
                  "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: non-exist.invalid"
                      + " \\([0-9]+ms\\)",
                  "(?s)\\[SendsStuff#login\\] java.net.UnknownHostException: non-exist.invalid.*",
                  "\\[SendsStuff#login\\] <--- END ERROR")
            }
          });
    }

    @MethodSource("data")
    @ParameterizedTest
    void formatCharacterEmits(Level logLevel, List<String> expectedMessages) {
      initFormatCharacterTest(logLevel, expectedMessages);
      SendsStuff api =
          Feign.builder()
              .logger(logger)
              .logLevel(logLevel)
              .retryer(
                  new Retryer() {
                    @Override
                    public void continueOrPropagate(RetryableException e) {
                      throw e;
                    }

                    @Override
                    public Retryer clone() {
                      return this;
                    }
                  })
              .target(SendsStuff.class, "http://non-exist.invalid");

      assertThrows(FeignException.class, () -> api.login("netflix", "denominator", "password"));
    }
  }

  public static class RetryEmitsTest extends LoggerTest {

    private Level logLevel;

    public void initRetryEmitsTest(Level logLevel, List<String> expectedMessages) {
      this.logLevel = logLevel;
      logger.expectMessages(expectedMessages);
    }

    public static Iterable<Object[]> data() {
      return Arrays.asList(
          new Object[][] {
            {Level.NONE, Collections.emptyList()},
            {
              Level.BASIC,
              Arrays.asList(
                  "\\[SendsStuff#login\\] ---> POST http://non-exist.invalid/ HTTP/1.1",
                  "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: non-exist.invalid"
                      + " \\([0-9]+ms\\)",
                  "\\[SendsStuff#login\\] ---> RETRYING",
                  "\\[SendsStuff#login\\] ---> POST http://non-exist.invalid/ HTTP/1.1",
                  "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: non-exist.invalid"
                      + " \\([0-9]+ms\\)")
            }
          });
    }

    @MethodSource("data")
    @ParameterizedTest
    void retryEmits(Level logLevel, List<String> expectedMessages) {

      initRetryEmitsTest(logLevel, expectedMessages);

      SendsStuff api =
          Feign.builder()
              .logger(logger)
              .logLevel(logLevel)
              .retryer(
                  new Retryer() {
                    boolean retried;

                    @Override
                    public void continueOrPropagate(RetryableException e) {
                      if (!retried) {
                        retried = true;
                        return;
                      }
                      throw e;
                    }

                    @Override
                    public Retryer clone() {
                      return this;
                    }
                  })
              .target(SendsStuff.class, "http://non-exist.invalid");

      assertThrows(FeignException.class, () -> api.login("netflix", "denominator", "password"));
    }
  }

  private static final class RecordingLogger extends Logger {

    private static final String PREFIX_X = "x-";
    private static final String PREFIX_Y = "y-";

    private final List<String> messages = new ArrayList<>();
    private final List<String> expectedMessages = new ArrayList<>();

    @Override
    protected boolean shouldLogRequestHeader(String header) {
      return !header.toLowerCase().startsWith(PREFIX_X);
    }

    @Override
    protected boolean shouldLogResponseHeader(String header) {
      return !header.toLowerCase().startsWith(PREFIX_Y);
    }

    void expectMessages(List<String> expectedMessages) {
      this.expectedMessages.addAll(expectedMessages);
    }

    @Override
    protected void log(String configKey, String format, Object... args) {
      messages.add(methodTag(configKey) + format.formatted(args));
    }

    // @Override
    // public Statement apply(final Statement base, Description description) {
    // return new Statement() {
    // @Override
    // public void evaluate() throws Throwable {
    // base.evaluate();
    // SoftAssertions softly = new SoftAssertions();
    // softly.assertThat(messages.size()).isEqualTo(expectedMessages.size());
    // for (int i = 0; i < messages.size() && i < expectedMessages.size(); i++) {
    // softly.assertThat(messages.get(i)).matches(expectedMessages.get(i));
    // }
    // softly.assertAll();
    // }
    // };
    // }
  }

  private static final class TestProtocolVersionClient extends Client.Default {
    private final String protocolVersionName;

    public TestProtocolVersionClient(String protocolVersionName) {
      super(null, null);
      this.protocolVersionName = protocolVersionName;
    }

    @Override
    Response convertResponse(HttpURLConnection connection, Request request) throws IOException {
      Response response = super.convertResponse(connection, request);
      if (nonNull((protocolVersionName))) {
        response =
            response.toBuilder()
                .protocolVersion(enumForName(ProtocolVersion.class, protocolVersionName))
                .build();
      }
      return response;
    }
  }
}
